Appearance
[中文版本](/posts/c教程/zh-CN/06-编译与构建/060-Practice Write Your Own Makefile)
Follow-Along Deliverable
- Assumed state: lessons 051-059 are complete and you can rebuild the previous example.
- Working directory:
~/c-course-labs/060-makefile-lab. - First command: on macOS / Linux run
mkdir -p ~/c-course-labs/060-makefile-lab && cd ~/c-course-labs/060-makefile-lab; on Windows PowerShell runNew-Item -ItemType Directory -Force "$HOME\c-course-labs\060-makefile-lab"; Set-Location "$HOME\c-course-labs\060-makefile-lab". - Success evidence: keep the source file, executable,
evidence.md, and recordmake, one incremental rebuild after editing a.cfile, andmake cleanoutput. - Boundary for this lab: This lab focuses on repeatable builds. CMake, Ninja, and release pipelines are out of scope.
- Reset: remove the executable, temporary data, and generated output for this lab; keep source and
evidence.mdfor review.
Now that you understand how the C build process works, preprocessing, compiling, linking, and libraries, it’s time to tie everything together with your own Makefile.
make is one of the oldest and most powerful automation tools in C development. It watches file timestamps, builds only what has changed, and lets you define build rules in a concise way.
By writing your own Makefile, you’ll automate your entire compilation workflow like a professional.
Step 1. Create the Project
Let’s build a small multi-file project:
project/
├── Makefile
├── main.c
├── math.c
├── math.h
└── string_utils.cmain.c
#include <stdio.h>
#include "math.h"
int main(void) {
printf("2 + 3 = %d\n", add(2, 3));
printf("2 * 3 = %d\n", mul(2, 3));
return 0;
}math.c
#include "math.h"
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }math.h
#ifndef MATH_H
#define MATH_H
int add(int a, int b);
int mul(int a, int b);
#endifStep 2. Write the Simplest Makefile
Makefile
main: main.c math.c
gcc main.c math.c -o mainRun:
make
./mainOutput:
2 + 3 = 5
2 * 3 = 6This works, butmake will rebuild everything every time, even if only one file changed.
Let’s make it smarter.
Step 3. Split the Compilation Steps
Separate compilation into object files:
CC = gcc
CFLAGS = -Wall -std=c99
main: main.o math.o
$(CC) $(CFLAGS) main.o math.o -o main
main.o: main.c math.h
$(CC) $(CFLAGS) -c main.c
math.o: math.c math.h
$(CC) $(CFLAGS) -c math.c
clean:
rm -f *.o mainNow when you runmake, it builds.o files only once, and recompiles only what changed.
Test it:
make
touch math.c
makeYou’ll see that onlymath.o is rebuilt.
Step 4. Add Automatic Dependency Handling
Use pattern rules to avoid repeating commands for every.c file:
CC = gcc
CFLAGS = -Wall -std=c99
OBJS = main.o math.o string_utils.o
TARGET = app
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) $(OBJS) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)$< means “the first dependency” (likemain.c).$@ means “the target” (likemain.o).
Now the Makefile works for any.c file automatically.
Step 5. Add Debug and Release Targets
Real projects have multiple build modes:
CC = gcc
CFLAGS = -Wall -std=c99
DEBUG_FLAGS = -g -O0 -DDEBUG
RELEASE_FLAGS = -O2 -DNDEBUG
OBJS = main.o math.o
TARGET = app
.PHONY: all clean debug release
all: release
debug: CFLAGS += $(DEBUG_FLAGS)
debug: $(TARGET)
release: CFLAGS += $(RELEASE_FLAGS)
release: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) $(OBJS) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)Run:
make debug
./appThen:
make clean
make releaseThe debug build has symbols forgdb; the release build is optimized.
Step 6. Add Static and Shared Library Targets
Add these to your Makefile:
libmylib.a: math.o
ar rcs libmylib.a math.o
libmylib.so: math.o
$(CC) -shared -o libmylib.so math.oNow you can build a reusable library:
make libmylib.a
make libmylib.soStep 7. Add Installation and Help
install:
cp app /usr/local/bin/
help:
@echo "make [target]"
@echo "Targets: all, debug, release, clean, install, libmylib.a, libmylib.so"The@ suppresses command echoing, only prints your messages.
Run:
make helpStep 8. Use Variables for Paths and Options
Clean up your Makefile by grouping related flags:
CC = gcc
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)
CFLAGS = -Wall -Wextra -std=c99
LDFLAGS = -lm
TARGET = app
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(TARGET) $(LDFLAGS)
clean:
rm -f $(OBJ) $(TARGET)wildcard andpatsubst let you automatically include new.c files as the project grows.
Tiny Code: Final Polished Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -O2
LDFLAGS = -lm
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)
TARGET = app
all: $(TARGET)
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) $(OBJ) -o $(TARGET) $(LDFLAGS)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJ) $(TARGET)Run:
make
./appThis pattern is simple, robust, and scalable, the foundation of nearly all C build systems.
Step 9. Makefile Debugging
To see what commandsmake is running:
make VERBOSE=1Or trace variable expansions:
make -pAdd@echo "Building $@" before commands for clarity.
Step 10. Why It Matters
A well-crafted Makefile:
- Automates your entire C build workflow
- Avoids recompiling unchanged files
- Scales to large multi-directory projects
- Makes your builds reproducible across systems
It’s your first step toward professional build systems like CMake, Meson, or Bazel, all of which build on these principles.
Try It Yourself
- Add a new
.cfile and watch your Makefile compile it automatically. - Add a
testtarget that compiles and runs unit tests. - Add colorized output using
tputor ANSI escapes. - Build both static and shared libraries in one run.
- Convert your Makefile to use multiple directories (
src/,include/,build/).
Congratulations! You’ve completed Chapter 6 – Compilation and the Build Process. You now understand how source becomes an executable, every stage from preprocessor to linker, and every tool in between.
Next, we’ll step deeper into Chapter 7: Working Close to the System, where your programs start interacting directly with the operating system through system calls, processes, and files.
